iT邦幫忙

2025 iThome 鐵人賽

DAY 7
0
Modern Web

React TDD 實戰:用 Vitest 打造可靠的前端應用系列 第 7

Day 07 - 測試替身基礎 🎭

  • 分享至 

  • xImage
  •  

今天要做什麼?

昨天我們學會了參數化測試,用優雅的方式處理大量測試資料。今天要解決一個新挑戰:「如何測試依賴外部服務的程式碼?」

想像你有個寄送通知的功能,它會真的寄出 email。測試時,你不希望真的寄信出去。這時候就需要「測試替身」來幫忙了!

學習目標

今天結束後,你將學會:

  • 理解測試替身的概念與種類
  • 掌握 Vitest 的 vi.fn()vi.spyOn() 用法
  • 學會 Stub、Mock、Spy 的使用場景
  • 掌握測試替身的最佳實踐

TDD 學習地圖

第一階段:打好基礎(Day 1-10)
├── Day 01 - 環境設置與第一個測試
├── Day 02 - 認識斷言(Assertions)
├── Day 03 - TDD 紅綠重構循環
├── Day 04 - 測試結構與組織
├── Day 05 - 測試生命週期
├── Day 06 - 參數化測試
├── Day 07 - 測試替身基礎 ★ 今天在這裡
├── ...
└── (更多精彩內容待續)

什麼是測試替身? 🎪

測試替身(Test Double)就像電影中的替身演員,在測試時代替真實的依賴物件。

三種主要類型

  1. Stub(存根) - 提供固定回應

    • 像是自動販賣機:投錢就給飲料
    • 不在乎被呼叫幾次
  2. Mock(模擬) - 驗證互動行為

    • 像是考官:檢查你有沒有做對步驟
    • 會驗證方法是否被正確呼叫
  3. Spy(間諜) - 監控真實行為

    • 像是監視器:記錄發生了什麼事
    • 保留原始功能,同時記錄呼叫

實戰演練 🚀

範例 1:使用 Mock 測試通知服務

先建立一個簡單的 EmailService 和 NotificationService。

建立 src/email_service.ts

export class EmailService {
  send(to: string, subject: string, body: string): boolean {
    // 實際實作會真的寄信
    console.log(`Sending email to ${to}`)
    return true
  }
}

建立 src/notification_service.ts

import { EmailService } from './email_service'

export class NotificationService {
  constructor(private emailService: EmailService) {}
  
  notify(userEmail: string, message: string): boolean {
    return this.emailService.send(
      userEmail, 
      'Notification', 
      message
    )
  }
}

建立 tests/day07/notification_service.test.ts

import { describe, it, expect, vi } from 'vitest'
import { NotificationService } from '../../src/notification_service'

describe('NotificationService with Mock', () => {
  it('sends email when notifying user', () => {
    // 建立 Mock
    const mockEmailService = {
      send: vi.fn().mockReturnValue(true)
    }
    
    const notificationService = new NotificationService(mockEmailService)
    
    // 執行測試
    const result = notificationService.notify('user@example.com', 'Hello!')
    
    // 驗證結果
    expect(result).toBe(true)
    
    // 驗證 Mock 被正確呼叫
    expect(mockEmailService.send).toHaveBeenCalledWith(
      'user@example.com',
      'Notification',
      'Hello!'
    )
    expect(mockEmailService.send).toHaveBeenCalledTimes(1)
  })
})

範例 2:使用 Stub 測試遊戲服務

建立 src/random_generator.ts

export class RandomGenerator {
  generate(min: number, max: number): number {
    return Math.floor(Math.random() * (max - min + 1)) + min
  }
}

建立 src/game_service.ts

import { RandomGenerator } from './random_generator'

export class GameService {
  constructor(private randomGenerator: RandomGenerator) {}
  
  rollDice(): number {
    return this.randomGenerator.generate(1, 6)
  }
  
  isWinning(diceValue: number): boolean {
    return diceValue >= 4
  }
}

建立 tests/day07/game_service.test.ts

import { describe, it, expect, vi } from 'vitest'
import { GameService } from '../../src/game_service'

describe('GameService with Stub', () => {
  it('wins when dice value is 4 or higher', () => {
    // 建立 Stub - 固定回傳 5
    const stubRandomGenerator = {
      generate: vi.fn().mockReturnValue(5)
    }
    
    const gameService = new GameService(stubRandomGenerator)
    
    const diceValue = gameService.rollDice()
    const isWin = gameService.isWinning(diceValue)
    
    expect(diceValue).toBe(5)
    expect(isWin).toBe(true)
  })
  
  it('loses when dice value is less than 4', () => {
    // 建立 Stub - 固定回傳 2
    const stubRandomGenerator = {
      generate: vi.fn().mockReturnValue(2)
    }
    
    const gameService = new GameService(stubRandomGenerator)
    
    const diceValue = gameService.rollDice()
    const isWin = gameService.isWinning(diceValue)
    
    expect(diceValue).toBe(2)
    expect(isWin).toBe(false)
  })
})

範例 3:使用 Spy 監控方法呼叫

建立 src/calculator_with_logger.ts

export class Logger {
  log(message: string): void {
    console.log(`[LOG] ${message}`)
  }
}

export class Calculator {
  constructor(private logger: Logger) {}
  
  add(a: number, b: number): number {
    const result = a + b
    this.logger.log(`Adding ${a} + ${b} = ${result}`)
    return result
  }
  
  subtract(a: number, b: number): number {
    const result = a - b
    this.logger.log(`Subtracting ${a} - ${b} = ${result}`)
    return result
  }
}

建立 tests/day07/calculator_with_spy.test.ts

import { describe, it, expect, vi } from 'vitest'
import { Calculator, Logger } from '../../src/calculator_with_logger'

describe('Calculator with Spy', () => {
  it('logs calculation when adding', () => {
    const logger = new Logger()
    // 使用 Spy 監控 log 方法
    const logSpy = vi.spyOn(logger, 'log')
    
    const calculator = new Calculator(logger)
    const result = calculator.add(2, 3)
    
    // 驗證計算結果
    expect(result).toBe(5)
    
    // 驗證 log 被呼叫
    expect(logSpy).toHaveBeenCalledWith('Adding 2 + 3 = 5')
    expect(logSpy).toHaveBeenCalledTimes(1)
  })
  
  it('logs calculation when subtracting', () => {
    const logger = new Logger()
    const logSpy = vi.spyOn(logger, 'log')
    
    const calculator = new Calculator(logger)
    const result = calculator.subtract(5, 3)
    
    expect(result).toBe(2)
    expect(logSpy).toHaveBeenCalledWith('Subtracting 5 - 3 = 2')
  })
})

使用時機 🎯

何時用 Stub?

  • 需要固定的測試資料
  • 外部服務的回應不重要
  • 想要控制測試環境

何時用 Mock?

  • 需要驗證方法被呼叫
  • 關心互動的正確性
  • 測試物件之間的協作

何時用 Spy?

  • 想要保留原始行為
  • 需要監控方法呼叫
  • 部分模擬真實物件

最佳實踐 💡

1. 保持測試簡單

// ✅ 好的做法:清楚的測試意圖
it('sends notification email', () => {
  const mockEmail = { send: vi.fn().mockReturnValue(true) }
  // ... 簡單明瞭的測試
})

// ❌ 避免:過度複雜的設置
it('does everything', () => {
  // 10 行的 mock 設置... 
})

2. 一次測一件事

// ✅ 好的做法:專注單一行為
it('calls email service with correct parameters', () => {
  // 只測試參數傳遞
})

it('returns true when email is sent successfully', () => {
  // 只測試回傳值
})

3. 適當的驗證

// ✅ 好的做法:驗證重要的互動
expect(mockService.send).toHaveBeenCalledWith(expectedParams)

// ❌ 避免:過度驗證
expect(mock.method1).toHaveBeenCalledTimes(1)
expect(mock.method2).toHaveBeenCalledTimes(2)
expect(mock.method3).toHaveBeenCalledTimes(3)
// ... 太多不必要的驗證

今日回顧 📝

今天我們學會了:

測試替身的三種類型

  • Stub:提供固定回應
  • Mock:驗證互動行為
  • Spy:監控真實物件

Vitest 測試工具

  • vi.fn():建立 Mock 函數
  • vi.spyOn():監控現有方法
  • mockReturnValue():設定回傳值

實務應用

  • EmailService 的 Mock 測試
  • GameService 的 Stub 測試
  • Calculator 的 Spy 測試

小練習 🏆

試著為以下 PaymentService 寫測試:

class PaymentService {
  constructor(
    private gateway: PaymentGateway,
    private logger: Logger
  ) {}
  
  processPayment(amount: number): boolean {
    this.logger.log(`Processing payment: $${amount}`)
    
    if (amount <= 0) {
      this.logger.log('Invalid amount')
      return false
    }
    
    const result = this.gateway.charge(amount)
    this.logger.log(`Payment result: ${result}`)
    
    return result
  }
}

提示:

  1. Mock PaymentGatewaycharge 方法
  2. Spy Loggerlog 方法
  3. 測試正常付款和無效金額的情況

明天我們將學習「例外處理測試」,了解如何測試錯誤情況! 🚀


上一篇
Day 06 - 參數化測試 🔢
下一篇
Day 08 - 例外處理測試 ⚠️
系列文
React TDD 實戰:用 Vitest 打造可靠的前端應用11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言